Vue 组件测试实战:如何防 AI 监守自盗
core 层是纯逻辑好测试,Vue 组件涉及 DOM、事件、slot,需要专门的工具与分层策略。这节用真实用例讲组件测试与集成测试的写法,并正面处理 AI 写测试最大的风险——AI 既当运动员(写实现)又当裁判员(写测试)的"监守自盗"问题。
两套组件测试工具的分工
真实仓库同时使用两套工具,不是重复建设,而是查询视角不同:
| 工具 | 视角 | 查什么 | 典型用例 |
|---|---|---|---|
| @vue/test-utils(VTU) | 实现契约 | mount 返回 wrapper,直查 props 传递、provide/inject 上下文、emitted() 事件 | packages/vue/__tests__/prompt-input/PromptInput.test.ts |
| @testing-library/vue(TL) | 用户行为 | 只走 role / 文本 / placeholder 等用户可感知信号,交互用 userEvent 模拟真实键盘鼠标 | packages/vue/__tests__/integration/chat-flow.test.ts |
选型经验一句话:验证组件对外契约用 VTU;验证"输入 → 发送 → 流式回复上屏"这类用户旅程用 TL(实现重构换类名时 TL 测试不用跟着改);不碰 DOM 的逻辑沉到 core 用纯单元测试。
组件命名对齐说明:课程早期叫 MessageBubble / InputArea,重构后真实组件为 Message 系(src/message/)与 PromptInput 系(src/prompt-input/),下文代码均按真实组件名。
VTU 组件测试:provide/inject 的探针模式
PromptInput 系组件通过 provide 向子组件下发上下文(文件管理、提交动作)。VTU 测这类组件族有个代表性技巧——在 default slot 里放一个探针组件,用 inject 拿到上下文逐项断言:
// packages/vue/__tests__/prompt-input/PromptInput.test.ts(真实用例,节选)
import { describe, it, expect, vi } from 'vitest'
import { defineComponent, h, inject } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import PromptInput from '../../src/prompt-input/PromptInput.vue'
import { PROMPT_INPUT_KEY, type PromptInputContext } from '../../src/prompt-input/context'
import type { Attachment } from '@toimc/core'
function png(name = 'a.png', size = 1024) {
return new File([new ArrayBuffer(size)], name, { type: 'image/png' })
}
const attachment = {
id: 'att-1',
url: 'https://example.com/a.png',
name: 'a.png',
mediaType: 'image/png',
size: 1024,
}
/** probe 模式:通过 default slot 内的探针组件拿到 provide 出来的上下文 */
let ctx: PromptInputContext
const Probe = defineComponent({
setup() {
ctx = inject(PROMPT_INPUT_KEY)!
return () => null
},
})
function mountInput(props: Record<string, unknown> = {}): VueWrapper {
ctx = undefined as unknown as PromptInputContext
return mount(PromptInput, { props, slots: { default: () => h(Probe) } })
}
describe('PromptInput', () => {
it('无文本但有文件时可发送,send 携带 files', async () => {
const w = mountInput()
const file = png()
ctx.addFiles([file])
ctx.inputText.value = ''
await ctx.submit()
expect(w.emitted('send')).toHaveLength(1)
const payload = w.emitted('send')![0][0] as { text: string; files?: File[] }
expect(payload.text).toBe('')
expect(payload.files).toEqual([file])
expect(ctx.pendingFiles.value).toHaveLength(0)
})
it('beforeSend 成功:uploading 后 emit send attachments 并清空', async () => {
const beforeSend = vi.fn().mockResolvedValue([attachment])
const w = mountInput({ beforeSend })
const file = png()
ctx.addFiles([file])
ctx.inputText.value = ' 看图 '
const pending = ctx.submit()
expect(ctx.pendingFiles.value[0].status).toBe('uploading') // 中间态
expect(w.emitted('send')).toBeUndefined()
await pending
expect(beforeSend).toHaveBeenCalledWith(file)
const payload = w.emitted('send')![0][0] as { attachments?: Attachment[] }
expect(payload.text).toBe('看图') // trim 过
expect(payload.attachments).toEqual([attachment])
expect(ctx.pendingFiles.value).toHaveLength(0)
})
})
typescript
这套断言能抓住"uploading 中间态、文本 trim、附件上传后清空"等只在真实时序里才暴露的行为。类似的还有 tool-call 组件族的 global.provide 直挂技巧:五个子组件共享 provide('toolCallData', data) 上下文,测子组件不必拼装真实外壳。
Testing Library 集成测试:宿主视角
集成测试按"真实宿主怎么组装"来拼装:Conversation / Message / MessageContent / PromptInput 加上 core 的 useChat,配一个流式 mock adapter,验证完整链路:
// packages/vue/__tests__/integration/chat-flow.test.ts(真实用例,节选)
import { render, screen, cleanup } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { useChat, type ChatAdapter, type StreamChunk } from '@toimc/core'
// ...组件导入省略
function textChunk(content: string): StreamChunk {
return { type: 'text', content }
}
/** 流式 mock adapter:按预置顺序 yield chunk,模拟后端 SSE 流 */
function makeAdapter(chunks: StreamChunk[]): ChatAdapter {
return {
async *sendMessage() {
for (const chunk of chunks) yield chunk
},
}
}
it('输入 → 发送 → 流式回复上屏', async () => {
const adapter = makeAdapter([
textChunk('你'), textChunk('好'), { type: 'done', content: '' },
])
render(createChatFlowDemo(adapter)) // 内联宿主组件,adapter 闭包注入
const user = userEvent.setup()
const box = screen.getByRole('textbox')
await user.type(box, '嗨')
await user.keyboard('{Enter}')
// 只断言用户可感知的信号,不碰任何内部类名
expect(await screen.findByText('你好')).toBeInTheDocument()
})
typescript
查询 API 三件套的记忆法:getBy*(找不到立刻抛错,用于必然存在的元素)、queryBy*(找不到返回 null,用于断言不存在)、findBy*(轮询等待异步出现)。
一条指令全量补测:实测数据
环境就绪后,让 AI 全量补测的指令就这么简单:
设计当前单元测试的用例。用例需要覆盖到正常、异常以及边界的场景,
把组件库里面的组件(所有单元组件)和核心逻辑都覆盖到。
text
不需要给太多上下文和角色设定。实测结果(2026-08 全量补测):
- 覆盖率:语句覆盖从 84.5% 提到 96.6%,分支 90.7%;按行覆盖口径三包从 87% 提到 98%
- 规模:新增 61 个测试文件、483 个用例(此后全仓持续增长,课程代码冻结时为 144+ 文件、1705 用例量级)
- 意外收获:补测过程暴露了一个真实生产 bug——
useChat的maxHistory选项一启用就栈溢出,AI 定位并修复 - 耗时:markdown 包测试约 7 秒,全量约 37 秒(当时机器)
补测完成后要求 AI 把结论同步进文档站:
当页面完成了单元测试之后,把上面的单元测试相关的内容更新到文档站。
text
AI 会按仓库 rules 里既定的文档规范来写(packages/docs/guide/unit-testing.md 记录了这次补测的方法论与数据)。这类"文档随测试自动更新"的活,后续还可以设计成 CI/CD 流水线任务。
watch 模式:测试驱动日常开发
手动跑全量用 pnpm test(对应 vitest run);日常开发用 pnpm test:watch(vitest)挂在旁边——它相当于启动了一个调试进程,保存文件后只重跑受影响的测试文件:改一下 Message 布局组件,对应的测试用例立即单独执行,模式标记、耗时状态都在终端实时可见。这和平时边改边看热更新是同一个工作节奏。
防 AI 监守自盗:本章核心
问题的本质
AI 自己写实现、又自己写测试时,断言容易与实现同源同错。同一个上下文里它顺手就把实现里的表达式复制进断言——于是实现把缩进写错,测试照样绿;覆盖率涨了,防御力没涨。绿色只证明"实现和测试一致",不证明"行为正确"。当组件多了以后你不可能一行行审核测试代码,效率还不如自己写。
四种无效测试的典型形态
| 形态 | 表现 | 为什么 AI 高发 |
|---|---|---|
| 同义反复断言 | 预期值由与实现相同的表达式算出,expect(f(x)).toBe(f(x)) 的变体 | AI 在同一上下文写实现和测试,顺手复用同一表达式 |
| 弱断言 | 只验证"渲染了"(toBeTruthy / 裸 not.toThrow()),不断言具体行为 | 省事且必绿 |
| mock 掉被测对象 | stub 写死一套返回值,测的是回声而非真实逻辑 | mock 越多测试越容易写绿 |
| 实现镜像 | 先有实现,再从实现反推测试 | 把 TDD 的方向整个反过来,测试只是实现的声音回放 |
前三种是"测试没有在测",第四种更隐蔽:测试存在、断言具体、也能失败,但它的正确性标准来自有 bug 的实现本身。
发现这些形态的途径有两个:用 local-search 搜"testing antipattern / tautological test"扩展清单;找多个模型互问"还有哪些无效测试场景"——场景收集得越丰富,规则覆盖越全。社区对此已有共识性讨论(AI 写的测试系统性同义反复、断言"代码做了什么"而非"该做什么")。
落地:把防线写进规则文件
问 AI"如何规避无效测试和无效测试用例的创建",让它调研并把结论直接落成 .claude/rules/frontend-testing.md 规则文件。规则里最关键的几条:
1. 预期值独立性——预期值只允许三个来源:手写的字面量、需求/文档写明的值、独立第三方实现(标准库、竞品行为、另一条不共享代码的路径)的输出。
// 反例:预期值用与实现完全相同的表达式计算——实现把缩进写错测试照样绿
expect(w.get('pre').text()).toBe(JSON.stringify({ city: 'Beijing' }, null, 2))
// 正例:预期值写死字面量(来自需求/手工计算),实现偏离格式时测试必须红
expect(w.get('pre').text()).toBe(`{
"city": "Beijing"
}`)
typescript
配套的弱断言黑名单:toBeTruthy() / toBeDefined() / 裸 not.toThrow(),异常用例必须有状态或错误内容的显式断言。
2. 红灯先行:每条新测试必须先失败过一次才算数——在实现缺失或故意改错的状态下跑出红灯(证明断言真的在校验,而非实现的声音回放),恢复实现转绿后提交。
3. 双盲分工:批量补测或新功能写测试时,用独立的 subagent 编写测试——prompt 里只提供文档页与类型定义,明确禁止读实现源码;测试与实现对拍通过后再合入。一个 AI 写、另一个 AI 测,互相监督。
双盲测试在仓库里的真实形态
packages/vue/__tests__/blind/ 目录存放双盲测试,命名 *.blind.test.ts,文件头声明契约来源:
/**
* 双盲测试:ThinkingChain
* 契约来源:packages/docs/components/thinking-chain.md(未读实现源码)
* ThinkingStep 类型来自 @toimc/core 类型契约
*/
import { ThinkingChain, setAiChatLocale } from '@toimc/vue'
import type { ThinkingStep } from '@toimc/core'
import { clickEl, findExactText, rendered, requireExactText } from './helpers'
it('折叠态头部仍显示标题、总耗时(默认各步求和)与步骤数', () => {
const steps: ThinkingStep[] = [
{ id: 'a', title: 'Step A', status: 'complete', duration: 800 },
{ id: 'b', title: 'Step B', status: 'pending', duration: 800 },
]
const w = mountChain({ steps, title: 'Reasoning plan' })
expect(w.text()).toContain('Reasoning plan')
// 800+800=1600ms ≥1000 → 1.6s(文档:复用 formatDuration)
expect(w.text()).toContain('1.6s')
})
typescript
双盲的公用 helpers.ts 里有个精心设计的约定,体现"按文档而非按实现"写测试:文档只承诺"折叠/收起/隐藏",没规定用 v-if 还是 v-show,于是 rendered() 把"DOM 移除"和 display:none 两种实现都判为不可见——测试不绑定实现细节。
五道防线与采纳策略
| 防线 | 成本 | 采纳 |
|---|---|---|
| 预期值独立性(三来源 + 黑名单) | 低,规则即生效 | ✅ 写进 frontend-testing.md |
| 红灯先行(补测试流程强制红灯证据) | 低,流程约定 | ✅ 写进 CLAUDE.md 测试流程 |
| 流程分离 / subagent 双盲 | 中,每次批量补测要开独立会话 | ✅ 写进 CLAUDE.md 测试流程 |
| 变异测试(mutation testing,机械化治本) | 高,跑变异要大量 token 与时间 | ⏸️ 暂缓——性价比权衡,后续按测试强度需要再引入 |
| 人肉抽检 | 低,抽几条读 | ✅ 抽检标准:测试读起来像规约(说明行为),而不像实现的复述 |
采纳原则:低成本的立即做(流程性约束进 CLAUDE.md),高消耗的按需后置。除 CLAUDE.md 外,AGENTS.md 等其他 AI 工具的约束配置文件同理——限制流程和行为,让后续每次写测试都自动走约好的路。
CLAUDE.md 里最终固化的"测试流程(防自证无效测试)"就两条纪律,值得原样记住:
- 红灯先行:写测试 → 在实现缺失或故意改错的状态下跑出红灯 → 恢复实现转绿 → 提交;批量补测时把红灯证据简述进 commit body
- 双盲分工:批量补测或新功能写测试用独立 subagent,prompt 只给文档页与类型定义、禁止读实现源码;单条小修可不启动双盲,但预期值独立性条款永远适用
用 AI 生成组件测试
基于 @toimc/vue 包中的 PromptInput 组件,生成完整的组件测试。
要求:
1. 使用 Vitest + @vue/test-utils + jsdom
2. 测试 Props(disabled/placeholder/beforeSend)
3. 测试 Events(send 携带 text/files/attachments)
4. 测试用户交互(输入文本/Enter发送/Shift+Enter换行/空内容不发送)
5. provide/inject 上下文用探针组件模式获取
6. 遵循 .claude/rules/frontend-testing.md:三场景 + AAA、预期值独立、无弱断言
text
注意最后一条:提示词里显式引用测试规则文件,让 AI 生成时就带着防监守自盗的约束,而不是生成完再人工排查。
小结
- 两套工具分工:VTU 查组件契约(props/emitted/provide 上下文),Testing Library 查用户行为(role/文本 + userEvent),不碰 DOM 的逻辑沉到 core
- 一条指令完成全量补测:语句覆盖 84.5%→96.6%,61 文件 483 用例,还揪出 maxHistory 栈溢出的真实 bug
- watch 模式下改代码即时重跑对应测试,与热更新同一节奏
- AI 监守自盗的四种形态:同义反复断言、弱断言、mock 被测对象、实现镜像
- 防线落进规则文件才有效:预期值三来源、红灯先行、subagent 双盲;变异测试按性价比后置
- 双盲测试的契约来源是文档页 + 类型定义,helpers 把 v-if/v-show 都归一为"不可见",测试不绑定实现细节
下一节配置测试覆盖率门禁与 CI 集成,并用 Playwright 做端到端测试。
↑